主题
RESTful 设计:让调用方知道一次请求究竟做了什么
假设你要给订单系统设计 API。创建订单超时后,客户端能不能重试?两个人同时修改地址,后保存的人会不会覆盖先保存的内容?翻第二页时又插入了新订单,会不会重复看到上一页?这些问题不会因为 URL 写成复数名词就消失。
RESTful 设计要把资源、操作语义、失败方式和演进规则放进一份稳定契约。本文用一个可修改的资源串起这些决定,并用 Go 1.27.2 的小实验验证条件请求、幂等去重与分页现象。
先确定资源,再决定 URL
资源是服务愿意让调用方识别和操作的对象,不等同于数据库中的一行。一个订单、订单集合、导出任务、某次退款都可以是资源。JSON 是资源的一种表示;同一资源也可能有不同语言、不同媒体类型的表示。
| 要表达的事情 | 可用的资源设计 | 这样设计方便什么 |
|---|---|---|
| 查询某订单 | GET /orders/42 | 稳定标识单个对象 |
| 创建订单 | POST /orders | 服务端分配 ID,返回新资源地址 |
| 查询订单下的退款 | GET /orders/42/refunds | 表达从属关系 |
| 创建退款 | POST /orders/42/refunds | 退款本身可查询、审计、有状态 |
| 启动大批量导出 | POST /exports | 返回任务资源,避免长时间占请求 |
| 查看导出进度 | GET /exports/9 | 轮询任务状态及下载地址 |
“资源化”并不要求把所有业务都伪装成 CRUD。例如审核、结算有自己的状态机和权限检查;可以建审核记录资源,也可以明确设计动作接口,关键是说清副作用与重复调用语义。不要让 GET /orders/42?delete=true 执行删除,因为缓存、爬虫、预取都可能自动发 GET。
严格的 REST 还包括客户端与服务端分离、无状态交互、缓存、统一接口、分层等约束;统一接口涉及通过表示和超媒体推动应用状态。许多现实中的 JSON API 只借用了其中一部分。本文讨论这些 API 最常用的设计手段,不把“用了 HTTP 和 JSON”当成满足全部 REST 约束的证明。Fielding 论文第 5 章
无状态也不是“服务器不能存数据”。订单当然要存。它要求服务器能从当前请求及资源状态理解请求,不依赖某台应用实例里隐含的“上次对话走到哪一步”。身份凭据、目标资源、操作参数都应明确,这样请求才能经负载均衡到不同实例。
方法语义决定客户端能做什么假设
| 方法 | 常见用途 | 安全 | 幂等 |
|---|---|---|---|
| GET、HEAD | 读取表示或元信息 | 是 | 是 |
| PUT | 用给定表示创建或替换目标资源 | 否 | 是 |
| DELETE | 删除目标资源关联 | 否 | 是 |
| POST | 由目标资源处理提交内容,例如创建子资源 | 否 | 无通用保证 |
| PATCH | 按补丁格式局部修改 | 否 | 无通用保证,取决于补丁含义 |
“安全”是调用方不请求改变资源状态,日志、计费统计等附带效果不在这个承诺内。“幂等”是同一请求重复执行,对服务器的预期效果与执行一次相同,不要求响应内容、时间戳或状态码完全相同。RFC 9110 §9.2
例如第一次 DELETE 返回 204,第二次因为资源已经不存在而返回 404,仍可符合幂等语义。PUT 把数量设成 5,重发后还是 5;PATCH 若表达“数量加 1”,重发就可能变成 6。PATCH 必须声明补丁媒体类型和并发控制方式,不能仅凭方法名判断可重试。RFC 5789 §2
为什么 POST 重试需要业务幂等键
客户端没收到响应,只能知道“结果未知”,无法知道服务器是否已经创建订单。若重发时重新生成一个幂等键,服务端会把它当成新操作。
正在绘制图示…
查看图源代码
sequenceDiagram
participant C as 客户端
participant A as API
participant D as 持久化存储
C->>A: 创建订单,key=K
A->>D: 原子检查 K,写订单和结果
D-->>A: 订单 42 已提交
A--xC: 响应丢失
C->>A: 同一输入、同一 key=K
A->>D: 查 K 与请求指纹
D-->>A: 已完成,结果为订单 42
A-->>C: 返回已记录的结果
文末的小型去重器把“检查键、比较输入、创建结果”放在同一把锁内。关键部分如下(省略错误返回以外的日志,完整函数见文末):
go
d.mu.Lock()
defer d.mu.Unlock()
digest := sha256.Sum256([]byte(body))
if old, ok := d.entries[key]; ok {
if old.digest != digest {
return 0, false, errors.New("key reused with different input")
}
return old.id, true, nil
}
d.next++
d.entries[key] = result{digest: digest, id: d.next}这里的 entries 需要先初始化;完整函数包含这一步。连续三次用同一键,输入分别是 book、book、pen,得到:
text
id=1 replay=false error=false
id=1 replay=true error=false
id=0 replay=false error=true第二次复用结果,第三次拒绝把旧键用在不同输入上。另一个测试让 20 个 goroutine 同时提交同一输入,断言只生成一个 ID,并通过 race 检查。
这是进程内算法示范。生产实现还要把键与租户或用户、操作名绑定,明确有效期、执行中状态、失败结果是否保存,并让幂等记录与业务写入具有同一事务边界。内存 map 在重启后丢失,不能当成支付或订单的可靠去重存储。外部副作用跨越事务时,需要下游同样支持幂等,或使用任务/消息与补偿设计。
状态码先表达大类,错误体再解释细节
| 情况 | 常用状态码 | 调用方应理解什么 |
|---|---|---|
| 返回表示 | 200 | 按声明的媒体类型读取 Body |
| 新资源已创建 | 201 | 通常用 Location 指向新资源 |
| 工作已接收,尚未完成 | 202 | 需要任务查询等后续机制;不保证最终成功 |
| 成功,无响应内容 | 204 | 不要再按 JSON 解码 |
| 条件读取发现未变化 | 304 | 重用已有表示;不是普通成功 JSON |
| 请求格式错误 | 400 | 修改输入,直接重试通常无用 |
| 缺少或无效身份凭据 | 401 | 结合 WWW-Authenticate 处理身份验证 |
| 无权执行 | 403 | 换同样凭据重试通常无用 |
| 资源不存在,或服务不愿透露其存在 | 404 | 不从状态码反推资源一定存在或不存在 |
| 方法不支持 | 405 | 读取 Allow |
| 与当前业务状态冲突 | 409 | 例如状态机不允许这次操作 |
| 条件请求前提不成立 | 412 | 重新获取版本,再决定是否合并 |
| 格式正确但内容不能处理 | 422 | 返回具体字段或业务校验问题 |
| 请求过多 | 429 | 按策略与 Retry-After 退避 |
| 服务暂时不可用 | 503 | 是否重试还要看操作语义与预算 |
各状态的规范定义见 RFC 9110 §15,429 和可用于强制条件写入的 428 见 RFC 6585。这张表是常见映射,不是让所有业务硬套唯一状态码。比如验证失败用 400 还是 422,需要在契约中统一。
不要所有错误都返回 HTTP 200,再让调用方读取 code != 0。HTTP 基础设施只看到了成功,监控、缓存和通用重试器便无法利用状态语义。
统一错误体可以采用 RFC 9457 的 Problem Details:
json
{
"type": "https://api.example/problems/invalid-count",
"title": "Invalid count",
"status": 422,
"detail": "count must be between 1 and 10",
"instance": "/requests/r-42"
}上面是格式示例,不是一个真实请求的输出。媒体类型是 application/problem+json。type 标识稳定的问题类型;title 是该类问题的简短说明;detail 解释这一次失败;instance 标识这次发生。客户端应依赖稳定的类型或自定义机器字段,别解析人类可读的英文句子。Body 中的 status 应与实际 HTTP 状态一致;不要泄露 SQL、堆栈、访问令牌或内部地址。RFC 9457 §3、§5
用条件请求避免重复传输和覆盖别人的修改
ETag 是资源表示的校验标识,不是必须等于数据库主键的字段。它可以随内容变化。弱 ETag 形如 W/"v1",只表达语义等价;If-Match 使用强比较,不能随便拿弱 ETag 做乐观锁。RFC 9110 §8.8、§13
| 请求 | 服务端判断 | 结果 |
|---|---|---|
GET 加 If-None-Match | 表示仍与缓存相符 | 304,不再传完整内容 |
PUT 加 If-Match | 客户端修改基于当前版本 | 满足才写入,否则 412 |
本 API 要求 If-Match,但没提供 | 无法检查覆盖风险 | 本文示例返回 428 |
本次 PUT 的核心是锁内完成版本比较与替换;这段来自文末 Handler,next 是已经解码、校验的新内容:
go
s.mu.Lock()
defer s.mu.Unlock()
tag := r.Header.Get("If-Match")
if tag == "" { problem(w, 428); return }
if tag != s.etag() { problem(w, 412); return }
s.value = next
w.WriteHeader(204)本次测试的过程如下,状态码都由断言验证:
text
GET 当前内容 → 200,得到 ETag A
GET If-None-Match: A → 304
PUT 新内容,If-Match: A → 204
PUT 另一内容,仍 If-Match: A → 412比较版本与写入必须原子执行。先查版本、释放锁、再写入,会让两个请求都通过检查。示例用 Mutex 包住两步;数据库实现通常用版本列条件更新或事务,并检查受影响行数。
示例只接受单个强 ETag 的精确匹配,没有实现 ETag 列表、星号、弱比较等完整 HTTP 语法。生产解析器要按规范处理,不能把这段教学字符串比较直接当成通用实现。示例 PUT 会把 JSON 解码后重新表示,未保存原始报文字节,因此成功 PUT 不直接发新的验证器,而让随后 GET 返回 ETag;这是为了遵守 PUT 对转换后表示返回验证器的限制。RFC 9110 §9.3.4
缓存控制也需要明确。no-cache 允许存储,但使用前需要验证;no-store 才要求不存储。含用户私有信息的表示不能不加区分地给共享缓存使用;private 限制共享缓存。变更响应也不等于所有缓存副本瞬间失效。RFC 9111 §5.2
分页、过滤和排序必须一起设计
先过滤再排序,然后取一页。排序要形成稳定的全序,例如 created_at DESC, id DESC。只有 created_at 时,多条记录时间相同,数据库可以用不同顺序返回它们,翻页便可能重复或遗漏。
| 方案 | 请求示例 | 优点 | 代价 |
|---|---|---|---|
| Offset | ?limit=20&offset=40 | 简单,容易跳页 | 前面插入/删除会移动偏移;深分页可能昂贵 |
| Cursor / keyset | ?limit=20&after=... | 可按稳定排序键继续向后取 | 一般不便任意跳页;游标需绑定查询条件 |
| 快照分页 | 游标还带快照标识 | 可保持一次遍历的稳定视图 | 需要存储或数据库支持,寿命与成本更高 |
用整数 ID 模拟升序列表,第一页取 [1 2],随后在前面插入 0。代码直接显示两种“继续”的含义:
go
rows := []int{1, 2, 3, 4}
fmt.Println("first:", rows[:2])
rows = append([]int{0}, rows...)
fmt.Println("offset=2:", rows[2:4])
after := slices.DeleteFunc(slices.Clone(rows), func(id int) bool { return id <= 2 })
fmt.Println("after=2:", after[:2])本次运行:
text
first: [1 2]
offset=2: [2 3]
after=2: [3 4]Offset 的第二页重复了 2。按键继续能避开这个例子的重复,但它仍不保证跨页快照一致:若排序字段可变,记录可能跨过游标;若后续记录被删,也不会神奇出现。导出财务报表等需要稳定集合的场景,要另建快照或明确截至时间。这里是基于列表实验的设计推论,没有声称测过任何数据库。
过滤字段、操作符和排序字段应列白名单;页大小设默认值和最大值。不要把客户端的排序文本直接拼成 SQL。游标可以封装排序键、过滤条件摘要和版本,并签名防篡改;Base64 只是一种编码,不提供保密或完整性。无论游标来自哪里,每页都要重新执行权限检查。
版本管理:改变路径并不能自动兼容
兼容性看的是已有调用方能否继续正确工作。给响应加一个可选字段通常较容易兼容,但严格拒绝未知字段的客户端可能失败;增加枚举值也可能打破客户端的穷举分支。删除字段、改变单位、把整数改成字符串、收紧输入范围,风险更明确。
| 做法 | 适合什么情况 | 要承担的维护 |
|---|---|---|
| 保持现有版本,增加可选能力 | 可以兼容旧客户端 | 保留默认行为,测试旧客户端 |
路径版本,如 /v2/orders | 契约确实有破坏性变化 | 并行运营版本、迁移、明确下线计划 |
| 媒体类型或请求头协商 | 已有完善内容协商体系 | 缓存 Vary、网关、SDK、文档都要配合 |
URI 版本不是 REST 的强制规则。选哪一种不如把迁移窗口、弃用通知和契约测试做清楚。分页游标本身也要有版本或过期策略,不能部署新排序规则后,继续把旧游标当成新格式解析。
OpenAPI 要写到能约束实现的程度
OpenAPI 描述 HTTP API 的路径、参数、请求与响应结构和安全方案。它不证明实现符合描述,也不会自动帮你处理幂等、权限或事务。文末给出本次实验的完整 openapi.json,固定使用 3.1.1,以免把“规范版本”和“工具当前支持版本”混为一谈。OpenAPI 3.1.1
最容易漏的不是成功示例,而是边界:哪些字段必填,缺失和 null 是否不同,最大页大小,数字范围,错误媒体类型,204 是否没有 Body,哪些响应带 ETag,写入是否要求 If-Match。它们同时进入测试,文档才不只是漂亮的展示页。
可以从契约生成服务端接口和客户端,再在 CI 中运行规范校验、破坏性变更检查和真实请求契约测试;也可以从代码生成描述,但仍需审查输出是否表达了业务限制。本文只验证了 JSON 文件语法与代码中的行为,没有运行 OpenAPI 完整规范验证器,也没有声称生成器能接受该文件。
资料与实测范围
- Fielding:REST 架构约束。
- RFC 9110:方法、条件请求、状态码。
- RFC 9111:Cache-Control。
- RFC 5789:PATCH 语义及并发修改。
- RFC 6585:428 与 429。
- RFC 9457:Problem Details。
- 固定规范:OpenAPI 3.1.1。
Go 1.27.2、Linux/amd64,go test、go test -race、go vet 全通过。验证覆盖 304、412、重复替换最终状态、并发去重及分页移动反例。未启动数据库、外部缓存或网关;可靠事务、缓存失效和实际查询代价是设计讨论,不是本次测量结论。
完整复现代码与契约
将以下文件放在同一目录。运行:
sh
go test -v -count=1 ./...
go test -race -count=1 ./...
go vet ./...
python -m json.tool openapi.json >/dev/null最后一条只检查 JSON 语法。示例聚焦条件请求,没有加入身份验证、持久化存储及完整媒体类型协商。
go.mod
go.mod
module example.com/webapi/02_rest
go 1.27.2rest_test.go
代码块 · 116 行 · 点击收起 / 展开
go
package restdemo
import (
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
)
type item struct {
Title string `json:"title"`
}
type store struct {
mu sync.Mutex
value item
}
func (s *store) etag() string { return fmt.Sprintf(`"%x"`, sha256.Sum256([]byte(s.value.Title))) }
func problem(w http.ResponseWriter, status int) {
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]any{"type": "about:blank", "title": http.StatusText(status), "status": status})
}
func (s *store) handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /items/1", func(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
defer s.mu.Unlock()
tag := s.etag()
w.Header().Set("ETag", tag)
w.Header().Set("Cache-Control", "private, no-cache")
// This exercise accepts exactly one strong ETag, not the full HTTP list grammar.
if r.Header.Get("If-None-Match") == tag {
w.WriteHeader(304)
return
}
w.Header().Set("Content-Type", "application/json")
if r.Method == http.MethodHead {
return
}
_ = json.NewEncoder(w).Encode(s.value)
})
mux.HandleFunc("PUT /items/1", func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 256)
defer r.Body.Close()
var next item
d := json.NewDecoder(r.Body)
d.DisallowUnknownFields()
if err := d.Decode(&next); err != nil || next.Title == "" {
problem(w, 400)
return
}
var extra any
if err := d.Decode(&extra); err != io.EOF {
problem(w, 400)
return
}
s.mu.Lock()
defer s.mu.Unlock()
tag := r.Header.Get("If-Match")
if tag == "" {
problem(w, 428)
return
}
if tag != s.etag() {
problem(w, 412)
return
}
s.value = next
// No ETag here: this server transforms JSON on storage, so PUT validator rules apply.
w.WriteHeader(204)
})
return mux
}
func TestConditionalUpdate(t *testing.T) {
s := &store{value: item{Title: "before"}}
h := s.handler()
call := func(method, body, header, value string) *httptest.ResponseRecorder {
r := httptest.NewRequest(method, "/items/1", strings.NewReader(body))
if header != "" {
r.Header.Set(header, value)
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
first := call("GET", "", "", "")
old := first.Header().Get("ETag")
if old == "" {
t.Fatal("missing validator")
}
if w := call("GET", "", "If-None-Match", old); w.Code != 304 {
t.Fatal(w.Code)
}
if w := call("PUT", `{"title":"after"}`, "If-Match", old); w.Code != 204 {
t.Fatal(w.Code)
}
if w := call("PUT", `{"title":"lost update"}`, "If-Match", old); w.Code != 412 || w.Header().Get("Content-Type") != "application/problem+json" {
t.Fatal(w.Code)
}
current := call("GET", "", "", "").Header().Get("ETag")
for i := 0; i < 2; i++ {
if w := call("PUT", `{"title":"after"}`, "If-Match", current); w.Code != 204 {
t.Fatal(w.Code)
}
}
if s.value.Title != "after" {
t.Fatal("unexpected final state")
}
t.Log("304, stale-write 412, and repeated replacement final state verified")
}teaching_test.go
代码块 · 78 行 · 点击收起 / 展开
go
package restdemo
import (
"crypto/sha256"
"errors"
"fmt"
"slices"
"sync"
"testing"
)
func Example_pagination() {
rows := []int{1, 2, 3, 4}
fmt.Println("first:", rows[:2])
rows = append([]int{0}, rows...)
fmt.Println("offset=2:", rows[2:4])
after := slices.DeleteFunc(slices.Clone(rows), func(id int) bool { return id <= 2 })
fmt.Println("after=2:", after[:2])
// Output:
// first: [1 2]
// offset=2: [2 3]
// after=2: [3 4]
}
type result struct {
digest [32]byte
id int
}
type deduper struct {
mu sync.Mutex
next int
entries map[string]result
}
func (d *deduper) create(key, body string) (int, bool, error) {
d.mu.Lock()
defer d.mu.Unlock()
digest := sha256.Sum256([]byte(body))
if old, ok := d.entries[key]; ok {
if old.digest != digest {
return 0, false, errors.New("key reused with different input")
}
return old.id, true, nil
}
d.next++
if d.entries == nil {
d.entries = make(map[string]result)
}
d.entries[key] = result{digest: digest, id: d.next}
return d.next, false, nil
}
func Example_idempotency() {
d := new(deduper)
for _, input := range []string{"book", "book", "pen"} {
id, replay, err := d.create("user-7:key-1", input)
fmt.Printf("id=%d replay=%v error=%v\n", id, replay, err != nil)
}
// Output:
// id=1 replay=false error=false
// id=1 replay=true error=false
// id=0 replay=false error=true
}
func TestConcurrentDedup(t *testing.T) {
d := new(deduper)
var wg sync.WaitGroup
for range 20 {
wg.Go(func() {
id, _, err := d.create("same", "book")
if id != 1 || err != nil {
t.Errorf("id=%d err=%v", id, err)
}
})
}
wg.Wait()
if d.next != 1 {
t.Fatal(d.next)
}
}openapi.json
json
{
"openapi": "3.1.1",
"info": {"title": "Conditional item example", "version": "1.0.0"},
"paths": {
"/items/1": {
"get": {
"operationId": "getItem",
"parameters": [{"name": "If-None-Match", "in": "header", "schema": {"type": "string"}}],
"responses": {
"200": {"description": "Current item", "headers": {"ETag": {"schema": {"type": "string"}}}, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Item"}}}},
"304": {"description": "Unchanged"}
}
},
"put": {
"operationId": "replaceItem",
"parameters": [{"name": "If-Match", "in": "header", "required": true, "schema": {"type": "string"}}],
"requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Item"}}}},
"responses": {
"204": {"description": "Replaced"},
"400": {"$ref": "#/components/responses/Problem"},
"412": {"$ref": "#/components/responses/Problem"},
"428": {"$ref": "#/components/responses/Problem"}
}
}
}
},
"components": {
"schemas": {
"Item": {"type": "object", "required": ["title"], "additionalProperties": false, "properties": {"title": {"type": "string", "minLength": 1}}},
"Problem": {"type": "object", "required": ["type", "title", "status"], "properties": {"type": {"type": "string"}, "title": {"type": "string"}, "status": {"type": "integer"}}}
},
"responses": {"Problem": {"description": "Request failed", "content": {"application/problem+json": {"schema": {"$ref": "#/components/schemas/Problem"}}}}}
}
}